--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 1459f80a6323d32affc66828b60e91e605ea4911
Parents : 96229b6
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-02T04:37:50-05:00
feat(ui): various improvemetns and fixes to interface components with improved layout, add bulk actions for favourites, and implement APK sharing functionality for Android
Changes
10 files changed, 777 insertions(+), 166 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index d96eb839..be5c9c9c 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -28,34 +28,44 @@
<template v-else>
<div
- class="sticky top-0 z-100 flex bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-xs transition-colors overflow-x-hidden"
+ class="sticky top-0 z-100 flex bg-white dark:bg-zinc-950 border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-xs transition-colors"
>
- <div class="flex w-full px-2 sm:px-4 overflow-x-auto no-scrollbar">
+ <div
+ class="flex w-full min-h-16 items-center gap-0 overflow-x-auto no-scrollbar pl-2 pr-2 sm:ps-0 sm:pe-4"
+ >
<button
type="button"
- class="sm:hidden my-auto mr-4 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
+ class="sm:hidden shrink-0 mr-2 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
@click="isSidebarOpen = !isSidebarOpen"
>
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
- <div
- class="my-auto mr-2 hidden w-10 shrink-0 cursor-pointer overflow-hidden rounded-xl sm:flex sm:w-14"
- @click="onAppNameClick"
- >
- <img class="h-10 w-10 object-contain p-1 sm:h-14 sm:w-14" :src="logoUrl" alt="" />
- </div>
- <div class="my-auto hidden sm:block">
- <div
- class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors tracking-tight text-lg"
- @click="onAppNameClick"
- >
- {{ $t("app.name") }}
+ <div class="flex min-w-0 flex-1 items-center gap-2 sm:flex-initial sm:gap-3">
+ <div class="hidden shrink-0 justify-start sm:flex sm:w-16 sm:justify-center">
+ <div
+ class="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-xl sm:h-14 sm:w-14"
+ @click="onAppNameClick"
+ >
+ <img
+ class="h-9 w-9 max-h-full max-w-full object-contain sm:h-[3.25rem] sm:w-[3.25rem]"
+ :src="logoUrl"
+ alt=""
+ />
+ </div>
</div>
- <div class="text-sm text-gray-600 dark:text-zinc-300">
- {{ $t("app.tagline") }}
+ <div class="hidden min-w-0 leading-tight sm:block">
+ <div
+ class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors tracking-tight text-lg"
+ @click="onAppNameClick"
+ >
+ {{ $t("app.name") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-zinc-300">
+ {{ $t("app.tagline") }}
+ </div>
</div>
</div>
- <div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
+ <div class="flex ml-auto shrink-0 items-center mr-0 sm:mr-2 space-x-1 sm:space-x-2">
<button
type="button"
class="relative hidden sm:inline-flex rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
diff --git a/meshchatx/src/frontend/components/contacts/ContactsPage.vue b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
index 8e090186..8b07da1f 100644
--- a/meshchatx/src/frontend/components/contacts/ContactsPage.vue
+++ b/meshchatx/src/frontend/components/contacts/ContactsPage.vue
@@ -409,6 +409,7 @@ import QRCode from "qrcode";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
+import DownloadUtils from "../../js/DownloadUtils";
import DialogUtils from "../../js/DialogUtils";
import LxmfUserIcon from "../LxmfUserIcon.vue";
@@ -605,14 +606,7 @@ export default {
const blob = new Blob([JSON.stringify({ contacts }, null, 2)], {
type: "application/json",
});
- const url = window.URL.createObjectURL(blob);
- const link = document.createElement("a");
- link.href = url;
- link.setAttribute("download", "contacts_export.json");
- document.body.appendChild(link);
- link.click();
- link.remove();
- window.URL.revokeObjectURL(url);
+ await DownloadUtils.downloadFile("contacts_export.json", blob);
ToastUtils.success(this.$t("contacts.export_success"));
} catch (e) {
ToastUtils.error(e?.response?.data?.message || this.$t("contacts.export_failed"));
diff --git a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
index 76b48cba..ca735bfd 100644
--- a/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
+++ b/meshchatx/src/frontend/components/debug/DebugLogsPage.vue
@@ -130,30 +130,37 @@
</div>
</div>
- <div class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-xs">
+ <div
+ class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-xs min-h-0"
+ >
<div
v-if="activeTab === 'logs'"
- class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
+ class="debug-log-scroll flex-1 overflow-auto p-2 sm:p-4 font-mono text-[9px] sm:text-[10px] md:text-xs max-sm:leading-snug sm:leading-snug md:leading-relaxed select-text touch-pan-x bg-white dark:bg-zinc-950 min-h-0"
>
- <div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10">
+ <div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10 text-sm sm:text-base">
{{ loading ? $t("debug.loading_logs") : $t("debug.no_logs") }}
</div>
<div
v-for="(log, index) in logs"
:key="index"
- class="border-b border-gray-100 dark:border-zinc-900 py-1.5 flex gap-2 sm:gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
+ class="debug-log-row border-b border-gray-100 dark:border-zinc-900 py-1 sm:py-1.5 flex gap-1.5 sm:gap-3 max-sm:flex-nowrap max-sm:min-w-max hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
:class="{ 'bg-red-50/30 dark:bg-red-900/10': log.is_anomaly }"
title="Tap to copy this log entry"
@click="copyLogLine(log)"
>
- <span class="text-gray-400 shrink-0">{{ formatTime(log.timestamp) }}</span>
- <span :class="levelClass(log.level)" class="w-12 shrink-0 font-bold uppercase">{{
- log.level
- }}</span>
- <span class="text-blue-500 shrink-0 w-20 sm:w-24 overflow-hidden text-ellipsis italic"
+ <span class="text-gray-400 shrink-0 max-sm:text-[8px]">{{ formatTime(log.timestamp) }}</span>
+ <span
+ :class="levelClass(log.level)"
+ class="w-11 sm:w-12 shrink-0 font-bold uppercase max-sm:text-[8px] max-sm:tracking-tight"
+ >{{ log.level }}</span
+ >
+ <span
+ class="text-blue-500 shrink-0 w-[4.5rem] sm:w-24 overflow-hidden text-ellipsis italic max-sm:text-[8px]"
>[{{ log.module }}]</span
>
- <span class="text-gray-800 dark:text-gray-200 wrap-break-word flex-1">
+ <span
+ class="text-gray-800 dark:text-gray-200 flex-1 max-sm:whitespace-nowrap sm:wrap-break-word"
+ >
{{ log.message }}
<span
v-if="log.is_anomaly"
@@ -168,30 +175,39 @@
<div
v-else
- class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
+ class="debug-log-scroll flex-1 overflow-auto p-2 sm:p-4 font-mono text-[9px] sm:text-[10px] md:text-xs max-sm:leading-snug sm:leading-snug md:leading-relaxed select-text touch-pan-x bg-white dark:bg-zinc-950 min-h-0"
>
- <div v-if="accessAttempts.length === 0" class="text-gray-500 italic text-center py-10">
+ <div
+ v-if="accessAttempts.length === 0"
+ class="text-gray-500 italic text-center py-10 text-sm sm:text-base"
+ >
{{ accessLoading ? $t("debug.loading_access") : $t("debug.no_access") }}
</div>
<div
v-for="row in accessAttempts"
:key="row.id"
- class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
+ class="border-b border-gray-100 dark:border-zinc-900 py-1.5 sm:py-2 flex flex-col gap-0.5 sm:gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
title="Tap to copy this access entry"
@click="copyAccessLine(row)"
>
- <div class="flex flex-wrap gap-x-3 gap-y-1 items-center">
+ <div
+ class="flex flex-wrap gap-x-2 gap-y-0.5 sm:gap-x-3 sm:gap-y-1 items-center max-sm:text-[9px]"
+ >
<span class="text-gray-400 shrink-0">{{ formatTime(row.created_at) }}</span>
<span class="text-amber-600 dark:text-amber-400 font-semibold">{{ row.outcome }}</span>
- <span class="text-cyan-600 dark:text-cyan-400">{{ row.method }} {{ row.path }}</span>
+ <span class="text-cyan-600 dark:text-cyan-400 max-sm:break-all sm:min-w-0"
+ >{{ row.method }} {{ row.path }}</span
+ >
</div>
- <div class="text-gray-600 dark:text-gray-400 break-all pl-0">
+ <div class="text-gray-600 dark:text-gray-400 break-all pl-0 max-sm:text-[8px]">
<span class="text-gray-500">IP</span> {{ row.client_ip }}
</div>
- <div class="text-gray-600 dark:text-gray-400 break-all">
+ <div class="text-gray-600 dark:text-gray-400 break-all max-sm:text-[8px]">
<span class="text-gray-500">UA</span> {{ row.user_agent || "—" }}
</div>
- <div v-if="row.detail" class="text-gray-500 text-[10px]">{{ row.detail }}</div>
+ <div v-if="row.detail" class="text-gray-500 max-sm:text-[8px] sm:text-[10px]">
+ {{ row.detail }}
+ </div>
</div>
</div>
@@ -481,4 +497,8 @@ export default {
.glass-card {
border-radius: 2px !important;
}
+.debug-log-scroll {
+ -webkit-text-size-adjust: 100%;
+ text-size-adjust: 100%;
+}
</style>
diff --git a/meshchatx/src/frontend/components/interfaces/Interface.vue b/meshchatx/src/frontend/components/interfaces/Interface.vue
index 14c14179..e58a22c4 100644
--- a/meshchatx/src/frontend/components/interfaces/Interface.vue
+++ b/meshchatx/src/frontend/components/interfaces/Interface.vue
@@ -47,6 +47,12 @@
<span :class="statusChipClass" class="shrink-0">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
}}</span>
+ <span
+ v-if="isReticulumRunning && isInterfaceEnabled(iface)"
+ :class="ifaceLinkStatusChipClass"
+ class="shrink-0"
+ >{{ ifaceLinkStatusLabel }}</span
+ >
<span v-if="isDiscoverable()" class="discoverable-chip shrink-0">Discoverable</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300 wrap-break-word min-w-0">
@@ -56,10 +62,10 @@
<span v-if="iface._stats?.bitrate" class="stat-chip"
>{{ $t("interface.bitrate") }} {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span
>
- <span class="stat-chip"
+ <span class="stat-chip" :class="{ 'stat-chip--zero-traffic': isIfaceStatBytesZero('txb') }"
>{{ $t("interface.tx") }} {{ formatBytes(iface._stats?.txb ?? 0) }}</span
>
- <span class="stat-chip"
+ <span class="stat-chip" :class="{ 'stat-chip--zero-traffic': isIfaceStatBytesZero('rxb') }"
>{{ $t("interface.rx") }} {{ formatBytes(iface._stats?.rxb ?? 0) }}</span
>
<span v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor" class="stat-chip"
@@ -259,6 +265,50 @@ export default {
? "inline-flex items-center rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs font-semibold"
: "inline-flex items-center rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-xs font-semibold";
},
+ ifaceLinkStatusKey() {
+ const v = this.normalizedIfaceLinkUp;
+ if (v === true) return "up";
+ if (v === false) return "down";
+ return null;
+ },
+ ifaceLinkStatusLabel() {
+ const key = this.ifaceLinkStatusKey;
+ if (key === "up") return this.$t("interface.link_up");
+ if (key === "down") return this.$t("interface.link_down");
+ return this.$t("interface.link_unknown");
+ },
+ ifaceLinkStatusChipClass() {
+ const key = this.ifaceLinkStatusKey;
+ if (key === "up") {
+ return "inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/45 dark:text-emerald-100 px-2 py-0.5 text-xs font-semibold";
+ }
+ if (key === "down") {
+ return "inline-flex items-center rounded-full bg-amber-100 text-amber-900 dark:bg-amber-900/40 dark:text-amber-100 px-2 py-0.5 text-xs font-semibold";
+ }
+ return "inline-flex items-center rounded-full bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-300 px-2 py-0.5 text-xs font-semibold";
+ },
+ normalizedIfaceLinkUp() {
+ if (!this.isReticulumRunning || !this.isInterfaceEnabled(this.iface)) {
+ return null;
+ }
+ const st = this.iface._stats;
+ if (!st || typeof st !== "object") {
+ return null;
+ }
+ if ("status" in st) {
+ const s = st.status;
+ if (s === true) return true;
+ if (s === false) return false;
+ if (typeof s === "string") {
+ const t = s.toLowerCase();
+ if (t === "up") return true;
+ if (t === "down") return false;
+ }
+ }
+ if (st.connected === true || st.online === true) return true;
+ if (st.connected === false || st.online === false) return false;
+ return null;
+ },
},
methods: {
onIFACSignatureClick: function (ifacSignature) {
@@ -298,6 +348,15 @@ export default {
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
+ isIfaceStatBytesZero(field) {
+ const st = this.iface._stats;
+ if (!st) {
+ return false;
+ }
+ const raw = st[field];
+ const n = raw == null ? 0 : Number(raw);
+ return (Number.isFinite(n) ? n : 0) === 0;
+ },
},
};
</script>
@@ -317,6 +376,9 @@ export default {
.stat-chip {
@apply inline-flex items-center rounded-full border border-gray-200 dark:border-zinc-700 px-2 py-0.5;
}
+.stat-chip--zero-traffic {
+ @apply border-red-400 bg-red-50 text-red-800 dark:border-red-700 dark:bg-red-950/50 dark:text-red-200 font-semibold;
+}
.ifac-line {
@apply text-xs flex flex-wrap items-center gap-1;
}
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index b9790e06..55c3ba8e 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -4,11 +4,11 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-linear-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
+ <div class="flex-1 overflow-y-auto overflow-x-hidden w-full px-3 sm:px-5 md:px-5 lg:px-8 py-3 sm:py-4">
<div class="space-y-0 w-full min-w-0 max-w-6xl xl:max-w-7xl 2xl:max-w-360 mx-auto flex-1">
<div
v-if="showRestartReminder"
- class="bg-amber-600 text-white border border-amber-500/30 p-4 sm:rounded-xl flex flex-wrap gap-3 items-center mb-4 sm:mb-6"
+ class="bg-amber-600 text-white border border-amber-500/30 p-4 sm:rounded-xl flex flex-wrap gap-3 items-center mb-3 sm:mb-4"
>
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
@@ -29,7 +29,7 @@
</div>
<div
- class="interfaces-section interfaces-section--hero flex flex-col lg:flex-row lg:items-center justify-between gap-6"
+ class="interfaces-section interfaces-section--hero flex flex-col lg:flex-row lg:items-center justify-between gap-4"
>
<div class="space-y-3 flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
@@ -255,7 +255,7 @@
<button
type="button"
class="secondary-chip text-xs"
- @click="loadDiscoveredInterfaces"
+ @click="refreshDiscoveredInterfacesList"
>
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
Refresh
@@ -911,11 +911,39 @@ export default {
return fromActive || fromStats;
},
},
+ watch: {
+ statusFilter(value) {
+ try {
+ localStorage.setItem("meshchatx.interfaces.statusFilter", value);
+ } catch {
+ /* ignore */
+ }
+ },
+ discoveredStatusFilter(value) {
+ try {
+ localStorage.setItem("meshchatx.interfaces.discoveredStatusFilter", value);
+ } catch {
+ /* ignore */
+ }
+ },
+ },
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
},
mounted() {
+ try {
+ const sf = localStorage.getItem("meshchatx.interfaces.statusFilter");
+ if (sf === "all" || sf === "enabled" || sf === "disabled") {
+ this.statusFilter = sf;
+ }
+ const df = localStorage.getItem("meshchatx.interfaces.discoveredStatusFilter");
+ if (df === "all" || df === "connected") {
+ this.discoveredStatusFilter = df;
+ }
+ } catch {
+ /* ignore */
+ }
this.loadInterfaces();
this.updateInterfaceStats();
this.loadDiscoveryConfig();
@@ -1088,8 +1116,10 @@ export default {
this.discoveredInterfaces = Array.from(merged.values());
this.discoveredActive = active;
+ return true;
} catch (e) {
console.log(e);
+ return false;
}
},
discoveryKey(iface) {
@@ -1475,6 +1505,14 @@ export default {
setStatusFilter(value) {
this.statusFilter = value;
},
+ async refreshDiscoveredInterfacesList() {
+ const ok = await this.loadDiscoveredInterfaces();
+ if (ok) {
+ ToastUtils.success(this.$t("interfaces.discovery_list_refreshed"));
+ } else {
+ ToastUtils.error(this.$t("interfaces.discovery_list_refresh_failed"));
+ }
+ },
filterChipClass(isActive) {
return isActive ? "primary-chip text-xs" : "secondary-chip text-xs";
},
@@ -1519,12 +1557,12 @@ export default {
<style scoped>
@reference "../../style.css";
.interfaces-section {
- @apply w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
+ @apply w-full border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6;
}
.interfaces-section--hero {
- @apply border-b border-gray-200/60 dark:border-zinc-800/60 py-6 sm:py-8;
+ @apply border-b border-gray-200/60 dark:border-zinc-800/60 py-4 sm:py-6;
}
.interfaces-subpanel {
- @apply mt-6 pt-6 border-t border-gray-200/50 dark:border-zinc-800/50 first:mt-0 first:pt-0 first:border-0;
+ @apply mt-4 pt-4 border-t border-gray-200/50 dark:border-zinc-800/50 first:mt-0 first:pt-0 first:border-0;
}
</style>
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index 58dc106f..118d730c 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -2643,11 +2643,11 @@ export default {
const cfg = this.config;
const a = cfg?.translator_argos_enabled;
const l = cfg?.translator_libretranslate_enabled;
- const onlyLibre = Boolean(l) && !a;
- if (onlyLibre) {
- return "auto";
+ if (!a && !l) {
+ return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
}
- return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
+
+ return "auto";
},
_readSavedTranslateTargetLang() {
let v = null;
@@ -2720,10 +2720,10 @@ export default {
const cfg = this.config;
const a = cfg?.translator_argos_enabled;
const l = cfg?.translator_libretranslate_enabled;
- if (l && !a) {
- return "auto";
+ if (!a && !l) {
+ return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
}
- return this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
+ return "auto";
},
onTranslateTargetBarClickOutside() {
if (this.isTranslateTargetModalWorking) {
@@ -2831,15 +2831,8 @@ export default {
return;
}
const useArgos = Boolean(a) && !l;
- const onlyLibre = Boolean(l) && !a;
const target = String(targetLang).toLowerCase().slice(0, 8);
- const source_lang = onlyLibre
- ? "auto"
- : this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
- if (useArgos && source_lang && target && source_lang === target) {
- ToastUtils.error(this.$t("messages.translate_target_invalid"));
- throw new Error("target");
- }
+ const source_lang = "auto";
this.isTranslatingMessage = true;
try {
const response = await window.api.post("/api/v1/translator/translate", {
@@ -2855,10 +2848,8 @@ export default {
});
}
} catch (e) {
- if (e?.message !== "target") {
- console.error("Translation failed:", e);
- ToastUtils.error(this.$t("messages.translation_failed"));
- }
+ console.error("Translation failed:", e);
+ ToastUtils.error(this.$t("messages.translation_failed"));
throw e;
} finally {
this.isTranslatingMessage = false;
@@ -2877,15 +2868,8 @@ export default {
return;
}
const useArgos = Boolean(a) && !l;
- const onlyLibre = Boolean(l) && !a;
const target = String(targetLang).toLowerCase().slice(0, 8);
- const source_lang = onlyLibre
- ? "auto"
- : this.normalizedLocaleCode(cfg?.language || this.$i18n.locale) || "en";
- if (useArgos && source_lang && target && source_lang === target) {
- ToastUtils.error(this.$t("messages.translate_target_invalid"));
- return;
- }
+ const source_lang = "auto";
this.messageBubbleTranslation[hash] = {
loading: true,
showOriginal: false,
diff --git a/meshchatx/src/frontend/components/messages/MessagesPage.vue b/meshchatx/src/frontend/components/messages/MessagesPage.vue
index 14a5649e..94f10cd8 100644
--- a/meshchatx/src/frontend/components/messages/MessagesPage.vue
+++ b/meshchatx/src/frontend/components/messages/MessagesPage.vue
@@ -2,6 +2,15 @@
<template>
<div class="flex flex-1 min-w-0 h-full overflow-hidden" :class="{ 'flex-row-reverse': messagesSidebarOnRight }">
+ <input
+ ref="foldersImportInput"
+ type="file"
+ accept=".json,application/json"
+ class="sr-only absolute m-[-1px] h-px w-px overflow-hidden border-0 p-0 whitespace-nowrap"
+ tabindex="-1"
+ aria-hidden="true"
+ @change="onFoldersImportFileSelected"
+ />
<MessagesSidebar
v-if="!isPopoutMode"
:class="{ 'hidden sm:flex': destinationHash }"
@@ -257,6 +266,7 @@ function snapshotGlobalConfig() {
return GlobalState.config && typeof GlobalState.config === "object" ? { ...GlobalState.config } : {};
}
import DialogUtils from "../../js/DialogUtils";
+import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
import ToastUtils from "../../js/ToastUtils";
import { lxmfConversationListPreview } from "../../js/lxmfReactions";
@@ -857,38 +867,39 @@ export default {
const response = await window.api.get("/api/v1/lxmf/folders/export");
const data = JSON.stringify(response.data, null, 2);
const blob = new Blob([data], { type: "application/json" });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = `meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`;
- a.click();
- URL.revokeObjectURL(url);
+ await DownloadUtils.downloadFile(
+ `meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`,
+ blob
+ );
} catch {
ToastUtils.error(this.$t("messages.failed_export_folders"));
}
},
- async onImportFolders() {
- const input = document.createElement("input");
- input.type = "file";
- input.accept = ".json";
- input.onchange = async (e) => {
- const file = e.target.files[0];
- if (!file) return;
- const reader = new FileReader();
- reader.onload = async (re) => {
- try {
- const data = JSON.parse(re.target.result);
- await window.api.post("/api/v1/lxmf/folders/import", data);
- await this.getFolders();
- await this.getConversations();
- ToastUtils.success(this.$t("messages.folders_imported"));
- } catch {
- ToastUtils.error(this.$t("messages.failed_import_folders"));
- }
- };
- reader.readAsText(file);
+ onImportFolders() {
+ const input = this.$refs.foldersImportInput;
+ if (input && typeof input.click === "function") {
+ input.click();
+ }
+ },
+ onFoldersImportFileSelected(event) {
+ const target = event.target;
+ const file = target.files?.[0];
+ target.value = "";
+ if (!file) return;
+
+ const reader = new FileReader();
+ reader.onload = async (re) => {
+ try {
+ const data = JSON.parse(re.target.result);
+ await window.api.post("/api/v1/lxmf/folders/import", data);
+ await this.getFolders();
+ await this.getConversations();
+ ToastUtils.success(this.$t("messages.folders_imported"));
+ } catch {
+ ToastUtils.error(this.$t("messages.failed_import_folders"));
+ }
};
- input.click();
+ reader.readAsText(file);
},
onFolderClick(folderId) {
this.selectedFolderId = folderId;
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index b3156f42..031fd562 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -18,6 +18,8 @@
@rename-favourite="onRenameFavourite"
@remove-favourite="onRemoveFavourite"
@add-favourite="addFavourite"
+ @bulk-remove-favourites="onBulkRemoveFavourites"
+ @bulk-add-favourites="onBulkAddFavouritesFromAnnounces"
@nodes-search-changed="onNodesSearchChanged"
@load-more-nodes="loadMoreNodes"
@toggle-collapse="nomadNetworkSidebarCollapsed = !nomadNetworkSidebarCollapsed"
@@ -76,8 +78,8 @@
<div class="my-auto dark:text-gray-100 flex-1 min-w-0 flex items-baseline gap-1 overflow-hidden">
<span
class="font-semibold truncate inline-block min-w-0 max-w-[min(100%,12rem)] sm:max-w-xs md:max-w-sm"
- :title="selectedNode.display_name"
- >{{ selectedNode.display_name }}</span
+ :title="selectedNode.custom_display_name || selectedNode.display_name"
+ >{{ selectedNode.custom_display_name || selectedNode.display_name }}</span
>
<span
v-if="selectedNodePath"
@@ -1102,6 +1104,49 @@ export default {
// update favourites
this.getFavourites();
},
+ async onBulkRemoveFavourites(hashes) {
+ if (!Array.isArray(hashes) || hashes.length === 0) {
+ return;
+ }
+ let removed = 0;
+ for (const h of hashes) {
+ try {
+ await window.api.delete(`/api/v1/favourites/${h}`);
+ removed += 1;
+ } catch (e) {
+ console.log(e);
+ }
+ }
+ await this.getFavourites();
+ if (removed > 0) {
+ ToastUtils.success(this.$t("nomadnet.bulk_remove_favourites_done", { count: removed }));
+ }
+ },
+ async onBulkAddFavouritesFromAnnounces(nodes) {
+ if (!Array.isArray(nodes) || nodes.length === 0) {
+ return;
+ }
+ let added = 0;
+ for (const node of nodes) {
+ if (this.isFavourite(node.destination_hash)) {
+ continue;
+ }
+ try {
+ await window.api.post("/api/v1/favourites/add", {
+ destination_hash: node.destination_hash,
+ display_name: node.display_name,
+ aspect: "nomadnetwork.node",
+ });
+ added += 1;
+ } catch (e) {
+ console.log(e);
+ }
+ }
+ await this.getFavourites();
+ if (added > 0) {
+ ToastUtils.success(this.$t("nomadnet.bulk_add_favourites_done", { count: added }));
+ }
+ },
async getNomadnetworkNodeAnnounces(append = false) {
try {
if (!append) {
@@ -1779,15 +1824,35 @@ export default {
if (displayName == null) {
return;
}
+ const trimmed = typeof displayName === "string" ? displayName.trim() : "";
+ if (!trimmed) {
+ return;
+ }
try {
// rename on server
await window.api.post(`/api/v1/favourites/${favourite.destination_hash}/rename`, {
- display_name: displayName,
+ display_name: trimmed,
});
// reload favourites
await this.getFavourites();
+
+ const dh = favourite.destination_hash;
+ if (this.nodes[dh]) {
+ this.nodes[dh] = {
+ ...this.nodes[dh],
+ custom_display_name: trimmed,
+ display_name: trimmed,
+ };
+ }
+ if (this.selectedNode?.destination_hash === dh) {
+ this.selectedNode = {
+ ...this.selectedNode,
+ custom_display_name: trimmed,
+ display_name: trimmed,
+ };
+ }
} catch (e) {
console.log(e);
ToastUtils.error(this.$t("nomadnet.failed_rename_favourite"));
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
index 340ffecd..6986cf81 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkSidebar.vue
@@ -77,7 +77,7 @@
? 'ring-2 ring-blue-500 ring-offset-1 ring-offset-white dark:ring-offset-zinc-950'
: 'hover:bg-white/10'
"
- :title="node.display_name"
+ :title="node.custom_display_name || node.display_name"
@click="onNodeClick(node)"
>
<MaterialDesignIcon icon-name="satellite-uplink" class="size-6 text-gray-600 dark:text-gray-300" />
@@ -116,18 +116,86 @@
</div>
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
- <div class="p-3 border-b border-gray-200 dark:border-zinc-800">
+ <div class="p-3 border-b border-gray-200 dark:border-zinc-800 space-y-2">
<input
v-model="favouritesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_favourites_placeholder', { count: favourites.length })"
class="input-field w-full rounded-none"
/>
+ <div
+ v-if="favouritesSelectionMode"
+ class="flex flex-col gap-2 px-2 py-2 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
+ >
+ <div class="flex items-center gap-2 min-w-0 w-full">
+ <div class="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
+ <input
+ type="checkbox"
+ :checked="allVisibleFavouritesSelected"
+ class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
+ @change="toggleSelectAllVisibleFavourites"
+ />
+ <span
+ class="text-xs font-semibold text-blue-700 dark:text-blue-400 truncate leading-none"
+ >
+ {{ $t("nomadnet.bulk_selected_count", { count: selectedFavouriteHashes.length }) }}
+ </span>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <div class="relative inline-flex items-center">
+ <button
+ type="button"
+ class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-blue-600 dark:text-blue-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
+ :disabled="selectedFavouriteHashes.length === 0"
+ @click="favouriteBulkMoveMenuOpen = !favouriteBulkMoveMenuOpen"
+ >
+ {{ $t("nomadnet.bulk_move_to_section") }}
+ </button>
+ <div
+ v-if="favouriteBulkMoveMenuOpen"
+ v-click-outside="{ handler: closeFavouriteBulkMoveMenu, capture: true }"
+ class="absolute right-0 top-full mt-1 z-60 min-w-[10rem] max-h-56 overflow-y-auto bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1"
+ >
+ <button
+ v-for="section in orderedSections"
+ :key="'bulk-move-' + section.id"
+ type="button"
+ class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700"
+ @click="bulkMoveSelectedFavouritesToSection(section.id)"
+ >
+ {{ section.name }}
+ </button>
+ </div>
+ </div>
+ <button
+ type="button"
+ class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-red-600 dark:text-red-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
+ :disabled="selectedFavouriteHashes.length === 0"
+ @click="bulkRemoveSelectedFavourites"
+ >
+ {{ $t("nomadnet.bulk_remove") }}
+ </button>
+ </div>
+ </div>
+ </div>
</div>
<div
class="flex items-center justify-between px-3 pt-2 text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
- <span class="font-semibold">Sections</span>
+ <div class="flex items-center gap-1 min-w-0">
+ <button
+ type="button"
+ class="shrink-0 inline-flex items-center justify-center p-0.5 rounded-sm text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors leading-none"
+ :title="$t('nomadnet.sidebar_selection_mode')"
+ :class="{ 'text-blue-500 dark:text-blue-400': favouritesSelectionMode }"
+ @click.stop="toggleFavouritesSelectionMode"
+ >
+ <span class="block size-[14px]">
+ <MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" />
+ </span>
+ </button>
+ <span class="font-semibold truncate">Sections</span>
+ </div>
<button
type="button"
class="inline-flex items-center gap-1 text-xs font-semibold text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"
@@ -138,14 +206,24 @@
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
- <div v-if="favourites.length === 0" class="empty-state">
+ <div v-if="favouritesSearchNoResults" class="empty-state empty-state--panel">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
- <div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
+ <div class="font-semibold">{{ $t("nomadnet.favourites_search_no_results") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
- {{ $t("nomadnet.add_nodes_from_announces") }}
+ {{ $t("nomadnet.favourites_search_try_other") }}
</div>
</div>
- <div v-else-if="hasFavouriteResults" class="space-y-3 pt-2">
+ <div v-else class="space-y-3 pt-2">
+ <div
+ v-if="favourites.length === 0"
+ class="empty-state empty-state--compact border border-dashed border-gray-200 dark:border-zinc-800 rounded-xl py-4 mb-1"
+ >
+ <MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
+ <div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
+ <div class="text-sm text-gray-500 dark:text-gray-400">
+ {{ $t("nomadnet.add_nodes_from_announces") }}
+ </div>
+ </div>
<div
v-for="section in sectionsWithFavourites"
:key="section.id"
@@ -221,18 +299,34 @@
favourite.destination_hash === selectedDestinationHash
? 'favourite-card--active'
: '',
- draggingFavouriteHash === favourite.destination_hash
+ isFavouriteRowDragging(favourite.destination_hash)
? 'favourite-card--dragging'
: '',
+ favouritesSelectionMode &&
+ selectedFavouriteHashes.includes(favourite.destination_hash)
+ ? 'ring-1 ring-blue-400/60 dark:ring-blue-500/50'
+ : '',
]"
draggable="true"
- @click="onFavouriteClick(favourite)"
+ @click="onFavouriteRowActivate(favourite)"
@contextmenu.prevent="openFavouriteContextMenu($event, favourite, section.id)"
@dragstart="onFavouriteDragStart($event, favourite, section.id)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, section.id, favourite)"
@dragend="onFavouriteDragEnd"
>
+ <div
+ v-if="favouritesSelectionMode"
+ class="my-auto mr-1 px-0.5 shrink-0"
+ @click.stop
+ >
+ <input
+ type="checkbox"
+ :checked="selectedFavouriteHashes.includes(favourite.destination_hash)"
+ class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500"
+ @change="toggleSelectFavourite(favourite.destination_hash)"
+ />
+ </div>
<div
v-if="
GlobalState.config.banished_effect_enabled &&
@@ -277,16 +371,11 @@
v-if="section.favourites.length === 0"
class="text-xs text-gray-500 dark:text-gray-400 px-3 pb-2 italic"
>
- No favourites in this section.
+ {{ $t("nomadnet.no_favourites_in_section") }}
</div>
</div>
</div>
</div>
- <div v-else class="empty-state">
- <MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
- <div class="font-semibold">No favourites match your search</div>
- <div class="text-sm text-gray-500 dark:text-gray-400">Try a different search term.</div>
- </div>
</div>
<!-- Favourite Context Menu (Teleport to body to avoid overflow clipping) -->
@@ -380,14 +469,65 @@
</div>
<div v-else class="flex-1 flex flex-col min-h-0">
- <div class="p-3 border-b border-gray-200 dark:border-zinc-800">
- <input
- :value="nodesSearchTerm"
- type="text"
- :placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
- class="input-field w-full rounded-none"
- @input="onNodesSearchInput"
- />
+ <div class="p-3 border-b border-gray-200 dark:border-zinc-800 space-y-2">
+ <div class="flex gap-1.5 items-center">
+ <input
+ :value="nodesSearchTerm"
+ type="text"
+ :placeholder="$t('nomadnet.search_placeholder_announces', { count: totalNodesCount })"
+ class="input-field flex-1 min-w-0 rounded-none"
+ @input="onNodesSearchInput"
+ />
+ <button
+ type="button"
+ class="shrink-0 self-center inline-flex items-center justify-center p-0.5 rounded-sm text-gray-400 hover:text-blue-500 dark:hover:text-blue-400 transition-colors leading-none"
+ :title="$t('nomadnet.sidebar_selection_mode')"
+ :class="{ 'text-blue-500 dark:text-blue-400': announcesSelectionMode }"
+ @click="toggleAnnouncesSelectionMode"
+ >
+ <span class="block size-[14px]">
+ <MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" />
+ </span>
+ </button>
+ </div>
+ <div
+ v-if="announcesSelectionMode"
+ class="flex flex-col gap-2 px-2 py-2 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
+ >
+ <div class="flex items-center gap-2 min-w-0 w-full">
+ <div class="flex items-center gap-2 min-w-0 flex-1 overflow-hidden">
+ <input
+ type="checkbox"
+ :checked="allVisibleAnnouncesSelected"
+ class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500 shrink-0"
+ @change="toggleSelectAllVisibleAnnounces"
+ />
+ <span
+ class="text-xs font-semibold text-blue-700 dark:text-blue-400 truncate leading-none"
+ >
+ {{ $t("nomadnet.bulk_selected_count", { count: selectedAnnounceHashes.length }) }}
+ </span>
+ </div>
+ <div class="flex items-center gap-2 shrink-0">
+ <button
+ type="button"
+ class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-yellow-600 dark:text-yellow-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
+ :disabled="selectedAnnounceHashes.length === 0"
+ @click="bulkAddSelectedAnnouncesToFavourites"
+ >
+ {{ $t("nomadnet.bulk_add_to_favourites") }}
+ </button>
+ <button
+ type="button"
+ class="inline-flex items-center whitespace-nowrap rounded px-0 py-0.5 text-xs font-bold leading-none text-red-600 dark:text-red-400 hover:underline disabled:pointer-events-none disabled:opacity-40"
+ :disabled="selectedAnnounceHashes.length === 0"
+ @click="bulkBanishSelectedAnnounces"
+ >
+ {{ $t("nomadnet.bulk_block_nodes") }}
+ </button>
+ </div>
+ </div>
+ </div>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4" @scroll="onNodesScroll">
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
@@ -395,7 +535,12 @@
v-for="node of searchedNodes"
:key="node.destination_hash"
class="announce-card relative"
- :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }"
+ :class="[
+ node.destination_hash === selectedDestinationHash ? 'announce-card--active' : '',
+ announcesSelectionMode && selectedAnnounceHashes.includes(node.destination_hash)
+ ? 'ring-1 ring-blue-400/60 dark:ring-blue-500/50'
+ : '',
+ ]"
@contextmenu.prevent="openAnnounceContextMenu($event, node)"
>
<!-- banished overlay -->
@@ -413,17 +558,25 @@
<div
class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
- @click="onNodeClick(node)"
+ @click="onAnnounceRowActivate(node)"
>
+ <div v-if="announcesSelectionMode" class="my-auto shrink-0 px-0.5" @click.stop>
+ <input
+ type="checkbox"
+ :checked="selectedAnnounceHashes.includes(node.destination_hash)"
+ class="rounded-sm border-gray-300 text-blue-600 focus:ring-blue-500"
+ @change="toggleSelectAnnounce(node.destination_hash)"
+ />
+ </div>
<div class="announce-card__icon shrink-0">
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
- :title="node.display_name"
+ :title="node.custom_display_name || node.display_name"
>
- {{ node.display_name }}
+ {{ node.custom_display_name || node.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 flex flex-col gap-0.5">
<span class="truncate">{{
@@ -473,7 +626,7 @@
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
</div>
</div>
- <div v-else class="empty-state">
+ <div v-else class="empty-state empty-state--panel">
<MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_announces_yet") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
@@ -596,17 +749,25 @@ export default {
"nodes-search-changed",
"load-more-nodes",
"toggle-collapse",
+ "bulk-remove-favourites",
+ "bulk-add-favourites",
],
data() {
return {
GlobalState,
tab: "favourites",
favouritesSearchTerm: "",
+ favouritesSelectionMode: false,
+ announcesSelectionMode: false,
+ selectedFavouriteHashes: [],
+ selectedAnnounceHashes: [],
+ favouriteBulkMoveMenuOpen: false,
defaultSectionId: "default",
sections: [],
sectionOrder: [],
favouritesBySection: {},
draggingFavouriteHash: null,
+ draggingFavouriteHashes: [],
draggingFavouriteSectionId: null,
dragOverSectionId: null,
draggingSectionId: null,
@@ -665,7 +826,8 @@ export default {
searchedNodes() {
return this.nodesOrderedByLatestAnnounce.filter((node) => {
const search = this.nodesSearchTerm.toLowerCase();
- const matchesDisplayName = node.display_name.toLowerCase().includes(search);
+ const label = (node.custom_display_name || node.display_name || "").toLowerCase();
+ const matchesDisplayName = label.includes(search);
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesDestinationHash;
});
@@ -689,14 +851,14 @@ export default {
return { ...section, favourites };
});
},
- hasFavouriteResults() {
+ favouritesSearchNoResults() {
if (this.favourites.length === 0) {
return false;
}
if (this.favouritesSearchTerm.trim() === "") {
- return true;
+ return false;
}
- return this.sectionsWithFavourites.some((section) => section.favourites.length > 0);
+ return !this.sectionsWithFavourites.some((section) => section.favourites.length > 0);
},
collapsedFavouritePreview() {
const out = [];
@@ -719,6 +881,26 @@ export default {
collapsedAnnounceNodesPreview() {
return this.nodesOrderedByLatestAnnounce.slice(0, 5);
},
+ flatVisibleFavouriteDestinationHashes() {
+ const out = [];
+ for (const section of this.sectionsWithFavourites) {
+ for (const fav of section.favourites) {
+ out.push(fav.destination_hash);
+ }
+ }
+ return out;
+ },
+ flatVisibleAnnounceDestinationHashes() {
+ return this.searchedNodes.map((n) => n.destination_hash);
+ },
+ allVisibleFavouritesSelected() {
+ const ids = this.flatVisibleFavouriteDestinationHashes;
+ return ids.length > 0 && ids.every((id) => this.selectedFavouriteHashes.includes(id));
+ },
+ allVisibleAnnouncesSelected() {
+ const ids = this.flatVisibleAnnounceDestinationHashes;
+ return ids.length > 0 && ids.every((id) => this.selectedAnnounceHashes.includes(id));
+ },
},
watch: {
favourites: {
@@ -727,6 +909,11 @@ export default {
},
deep: true,
},
+ tab() {
+ this.exitFavouritesSelectionMode();
+ this.exitAnnouncesSelectionMode();
+ this.favouriteBulkMoveMenuOpen = false;
+ },
},
mounted() {
this.loadFavouriteLayout();
@@ -751,6 +938,151 @@ export default {
}
},
methods: {
+ toggleFavouritesSelectionMode() {
+ this.favouritesSelectionMode = !this.favouritesSelectionMode;
+ if (!this.favouritesSelectionMode) {
+ this.selectedFavouriteHashes = [];
+ }
+ this.favouriteBulkMoveMenuOpen = false;
+ },
+ toggleAnnouncesSelectionMode() {
+ this.announcesSelectionMode = !this.announcesSelectionMode;
+ if (!this.announcesSelectionMode) {
+ this.selectedAnnounceHashes = [];
+ }
+ },
+ exitFavouritesSelectionMode() {
+ this.favouritesSelectionMode = false;
+ this.selectedFavouriteHashes = [];
+ this.favouriteBulkMoveMenuOpen = false;
+ },
+ exitAnnouncesSelectionMode() {
+ this.announcesSelectionMode = false;
+ this.selectedAnnounceHashes = [];
+ },
+ toggleSelectFavourite(hash) {
+ const i = this.selectedFavouriteHashes.indexOf(hash);
+ if (i >= 0) {
+ this.selectedFavouriteHashes.splice(i, 1);
+ } else {
+ this.selectedFavouriteHashes.push(hash);
+ }
+ },
+ toggleSelectAnnounce(hash) {
+ const i = this.selectedAnnounceHashes.indexOf(hash);
+ if (i >= 0) {
+ this.selectedAnnounceHashes.splice(i, 1);
+ } else {
+ this.selectedAnnounceHashes.push(hash);
+ }
+ },
+ toggleSelectAllVisibleFavourites() {
+ const ids = this.flatVisibleFavouriteDestinationHashes;
+ if (ids.length === 0) {
+ return;
+ }
+ if (ids.every((id) => this.selectedFavouriteHashes.includes(id))) {
+ this.selectedFavouriteHashes = this.selectedFavouriteHashes.filter((h) => !ids.includes(h));
+ } else {
+ this.selectedFavouriteHashes = [...new Set([...this.selectedFavouriteHashes, ...ids])];
+ }
+ },
+ toggleSelectAllVisibleAnnounces() {
+ const ids = this.flatVisibleAnnounceDestinationHashes;
+ if (ids.length === 0) {
+ return;
+ }
+ if (ids.every((id) => this.selectedAnnounceHashes.includes(id))) {
+ this.selectedAnnounceHashes = this.selectedAnnounceHashes.filter((h) => !ids.includes(h));
+ } else {
+ this.selectedAnnounceHashes = [...new Set([...this.selectedAnnounceHashes, ...ids])];
+ }
+ },
+ onFavouriteRowActivate(favourite) {
+ if (this.isBlocked(favourite.destination_hash)) {
+ return;
+ }
+ if (this.favouritesSelectionMode) {
+ this.toggleSelectFavourite(favourite.destination_hash);
+ return;
+ }
+ this.onFavouriteClick(favourite);
+ },
+ onAnnounceRowActivate(node) {
+ if (this.isBlocked(node.identity_hash || node.destination_hash)) {
+ return;
+ }
+ if (this.announcesSelectionMode) {
+ this.toggleSelectAnnounce(node.destination_hash);
+ return;
+ }
+ this.onNodeClick(node);
+ },
+ isFavouriteRowDragging(destinationHash) {
+ return this.draggingFavouriteHashes.includes(destinationHash);
+ },
+ closeFavouriteBulkMoveMenu() {
+ this.favouriteBulkMoveMenuOpen = false;
+ },
+ bulkMoveSelectedFavouritesToSection(sectionId) {
+ if (!sectionId || this.selectedFavouriteHashes.length === 0) {
+ this.closeFavouriteBulkMoveMenu();
+ return;
+ }
+ this.moveFavouritesToSection([...this.selectedFavouriteHashes], sectionId);
+ this.closeFavouriteBulkMoveMenu();
+ this.exitFavouritesSelectionMode();
+ },
+ async bulkRemoveSelectedFavourites() {
+ const hashes = [...this.selectedFavouriteHashes];
+ if (hashes.length === 0) {
+ return;
+ }
+ if (
+ !(await DialogUtils.confirm(
+ this.$t("nomadnet.bulk_remove_favourites_confirm", { count: hashes.length })
+ ))
+ ) {
+ return;
+ }
+ this.$emit("bulk-remove-favourites", hashes);
+ this.exitFavouritesSelectionMode();
+ },
+ async bulkBanishSelectedAnnounces() {
+ const nodes = this.selectedAnnounceHashes
+ .map((h) => this.nodes[h])
+ .filter((n) => n && !this.isBlocked(n.identity_hash) && !this.isBlocked(n.destination_hash));
+ if (nodes.length === 0) {
+ return;
+ }
+ if (!(await DialogUtils.confirm(this.$t("nomadnet.bulk_block_confirm", { count: nodes.length })))) {
+ return;
+ }
+ try {
+ for (const node of nodes) {
+ await window.api.post("/api/v1/blocked-destinations", {
+ destination_hash: node.identity_hash,
+ });
+ }
+ GlobalEmitter.emit("block-status-changed");
+ ToastUtils.success(this.$t("nomadnet.bulk_block_done", { count: nodes.length }));
+ } catch (e) {
+ DialogUtils.alert(this.$t("nomadnet.failed_to_block_node"));
+ console.error(e);
+ }
+ this.exitAnnouncesSelectionMode();
+ },
+ bulkAddSelectedAnnouncesToFavourites() {
+ const nodes = this.selectedAnnounceHashes
+ .map((h) => this.nodes[h])
+ .filter((n) => n && !this.isFavourite(n.destination_hash));
+ if (nodes.length === 0) {
+ ToastUtils.info(this.$t("nomadnet.bulk_nothing_to_add_favourites"));
+ return;
+ }
+ this.$emit("bulk-add-favourites", nodes);
+ this.exitAnnouncesSelectionMode();
+ },
startEditingSection(section) {
this.editingSectionId = section.id;
this.editingSectionName = section.name;
@@ -958,14 +1290,28 @@ export default {
this.$emit("remove-favourite", favourite);
},
onFavouriteDragStart(event, favourite, sectionId) {
+ let hashes;
+ if (
+ this.favouritesSelectionMode &&
+ this.selectedFavouriteHashes.includes(favourite.destination_hash) &&
+ this.selectedFavouriteHashes.length > 1
+ ) {
+ hashes = [...this.selectedFavouriteHashes];
+ } else {
+ hashes = [favourite.destination_hash];
+ }
try {
if (event?.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", favourite.destination_hash);
+ if (hashes.length > 1) {
+ event.dataTransfer.setData("application/x-meshchat-nomad-favs", JSON.stringify(hashes));
+ }
}
} catch {
// ignore for browsers that prevent setting drag meta
}
+ this.draggingFavouriteHashes = hashes;
this.draggingFavouriteHash = favourite.destination_hash;
this.draggingFavouriteSectionId = sectionId;
},
@@ -975,13 +1321,23 @@ export default {
}
},
onFavouriteDrop(event, targetSectionId, targetFavourite) {
- if (!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash) {
+ const hashes =
+ this.draggingFavouriteHashes.length > 0
+ ? [...this.draggingFavouriteHashes]
+ : this.draggingFavouriteHash
+ ? [this.draggingFavouriteHash]
+ : [];
+ if (!hashes.length) {
return;
}
- this.moveFavouriteToSection(this.draggingFavouriteHash, targetSectionId, targetFavourite.destination_hash);
+ if (hashes.includes(targetFavourite.destination_hash)) {
+ return;
+ }
+ this.moveFavouritesToSection(hashes, targetSectionId, targetFavourite.destination_hash);
},
onFavouriteDragEnd() {
this.draggingFavouriteHash = null;
+ this.draggingFavouriteHashes = [];
this.draggingFavouriteSectionId = null;
this.dragOverSectionId = null;
},
@@ -992,10 +1348,16 @@ export default {
this.dragOverSectionId = null;
},
onDropOnSection(sectionId) {
- if (!this.draggingFavouriteHash) {
+ const hashes =
+ this.draggingFavouriteHashes.length > 0
+ ? [...this.draggingFavouriteHashes]
+ : this.draggingFavouriteHash
+ ? [this.draggingFavouriteHash]
+ : [];
+ if (!hashes.length) {
return;
}
- this.moveFavouriteToSection(this.draggingFavouriteHash, sectionId);
+ this.moveFavouritesToSection(hashes, sectionId);
},
onSectionDragStart(sectionId) {
this.draggingSectionId = sectionId;
@@ -1029,32 +1391,42 @@ export default {
this.draggingSectionOverId = null;
},
moveFavouriteToSection(hash, targetSectionId, beforeHash = null) {
- if (!hash || !targetSectionId) {
+ this.moveFavouritesToSection([hash], targetSectionId, beforeHash);
+ },
+ moveFavouritesToSection(hashes, targetSectionId, beforeHash = null) {
+ const unique = [...new Set((hashes || []).filter(Boolean))];
+ if (!unique.length || !targetSectionId) {
return;
}
const updated = {};
Object.keys(this.favouritesBySection).forEach((sectionKey) => {
- updated[sectionKey] = (this.favouritesBySection[sectionKey] || []).filter((value) => value !== hash);
+ updated[sectionKey] = [...(this.favouritesBySection[sectionKey] || [])].filter(
+ (value) => !unique.includes(value)
+ );
});
if (!updated[targetSectionId]) {
updated[targetSectionId] = [];
}
- const targetList = [...updated[targetSectionId]];
- if (beforeHash) {
+
+ let targetList = [...updated[targetSectionId]];
+
+ if (beforeHash && !unique.includes(beforeHash)) {
const insertIndex = targetList.indexOf(beforeHash);
if (insertIndex === -1) {
- targetList.push(hash);
+ targetList.push(...unique);
} else {
- targetList.splice(insertIndex, 0, hash);
+ targetList.splice(insertIndex, 0, ...unique);
}
} else {
- targetList.push(hash);
+ targetList.push(...unique);
}
+
updated[targetSectionId] = targetList;
this.favouritesBySection = updated;
this.persistFavouriteLayout();
this.draggingFavouriteHash = null;
+ this.draggingFavouriteHashes = [];
this.draggingFavouriteSectionId = null;
this.dragOverSectionId = null;
},
@@ -1120,6 +1492,7 @@ export default {
this.favouriteContextMenu.show = false;
this.sectionContextMenu.show = false;
this.announceContextMenu.show = false;
+ this.favouriteBulkMoveMenuOpen = false;
},
openAnnounceContextMenu(event, node) {
this.announceContextMenu = {
@@ -1331,6 +1704,12 @@ export default {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
}
.empty-state {
- @apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
+ @apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400;
+}
+.empty-state--compact {
+ @apply justify-center py-3;
+}
+.empty-state--panel {
+ @apply min-h-[min(50vh,18rem)] py-10 sm:min-h-[min(45vh,20rem)];
}
</style>
diff --git a/meshchatx/src/frontend/components/settings/SettingsPage.vue b/meshchatx/src/frontend/components/settings/SettingsPage.vue
index db407687..a3a04b96 100644
--- a/meshchatx/src/frontend/components/settings/SettingsPage.vue
+++ b/meshchatx/src/frontend/components/settings/SettingsPage.vue
@@ -721,6 +721,37 @@
</div>
</section>
+ <section
+ v-if="isMeshChatXAndroid"
+ v-show="matchesSearch(...sectionKeywords.android)"
+ class="settings-section break-inside-avoid"
+ >
+ <header class="settings-section__header">
+ <div>
+ <div class="settings-section__eyebrow">Android</div>
+ <h2>{{ $t("settings.share_apk_heading") }}</h2>
+ <p>{{ $t("settings.share_apk_desc") }}</p>
+ </div>
+ </header>
+ <div class="settings-section__body space-y-4">
+ <button
+ type="button"
+ class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
+ @click="shareAndroidApk"
+ >
+ <div class="flex flex-col items-start text-left">
+ <div class="font-bold flex items-center gap-2">
+ <MaterialDesignIcon icon-name="share-variant" class="size-4" />
+ {{ $t("settings.share_apk") }}
+ </div>
+ <div class="text-xs opacity-80">
+ {{ $t("settings.share_apk_short_hint") }}
+ </div>
+ </div>
+ </button>
+ </div>
+ </section>
+
<!-- Page Archiver -->
<section
v-show="matchesSearch(...sectionKeywords.archiver)"
@@ -2449,6 +2480,7 @@ import ShortcutRecorder from "./ShortcutRecorder.vue";
import SettingsSectionBlock from "./SettingsSectionBlock.vue";
import KeyboardShortcuts from "../../js/KeyboardShortcuts";
import ElectronUtils from "../../js/ElectronUtils";
+import AndroidBridge from "../../js/rnode/AndroidBridge";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import StickerPacksManager from "../stickers/StickerPacksManager.vue";
import GlobalState from "../../js/GlobalState";
@@ -2677,6 +2709,16 @@ export default {
"app.desktop_hardware_acceleration_enabled",
"app.desktop_hardware_acceleration_enabled_description",
],
+ android: [
+ "Android",
+ "APK",
+ "Bluetooth",
+ "Nearby Share",
+ "settings.share_apk_heading",
+ "settings.share_apk_desc",
+ "settings.share_apk",
+ "settings.share_apk_short_hint",
+ ],
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
nomadRenderer: [
"NomadNet",
@@ -2881,6 +2923,14 @@ export default {
const c = this.config?.lxmf_inbound_stamp_cost;
return (typeof c === "number" ? c : Number(c) || 0) > 0;
},
+ isMeshChatXAndroid() {
+ return (
+ typeof window !== "undefined" &&
+ window.MeshChatXAndroid &&
+ typeof window.MeshChatXAndroid.getPlatform === "function" &&
+ window.MeshChatXAndroid.getPlatform() === "android"
+ );
+ },
},
beforeUnmount() {
// stop listening for websocket messages
@@ -2943,6 +2993,12 @@ export default {
clearSettingsSearch() {
this.searchQuery = "";
},
+ shareAndroidApk() {
+ const bridge = new AndroidBridge();
+ if (!bridge.shareApk()) {
+ ToastUtils.error(this.$t("settings.share_apk_failed"));
+ }
+ },
matchesSearch(...texts) {
return matchesSettingSearch(texts, (k) => this.$t(k), this.searchQuery);
},
@@ -3875,14 +3931,9 @@ export default {
const response = await window.api.get("/api/v1/maintenance/messages/export");
const messages = response.data.messages;
const dataStr = JSON.stringify({ messages }, null, 2);
- const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
-
+ const blob = new Blob([dataStr], { type: "application/json" });
const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
-
- const linkElement = document.createElement("a");
- linkElement.setAttribute("href", dataUri);
- linkElement.setAttribute("download", exportFileDefaultName);
- linkElement.click();
+ await DownloadUtils.downloadFile(exportFileDefaultName, blob);
} catch {
ToastUtils.error(this.$t("common.error"));
}
@@ -3916,12 +3967,9 @@ export default {
try {
const response = await window.api.get("/api/v1/lxmf/folders/export");
const dataStr = JSON.stringify(response.data, null, 2);
- const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
+ const blob = new Blob([dataStr], { type: "application/json" });
const exportFileDefaultName = `meshchat_folders_${new Date().toISOString().slice(0, 10)}.json`;
- const linkElement = document.createElement("a");
- linkElement.setAttribute("href", dataUri);
- linkElement.setAttribute("download", exportFileDefaultName);
- linkElement.click();
+ await DownloadUtils.downloadFile(exportFileDefaultName, blob);
ToastUtils.success(this.$t("settings.folders_exported"));
} catch {
ToastUtils.error(this.$t("settings.failed_export_folders"));
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────